Skip to content

iOS: offline cache expiry/eviction, cold-start fix, check-in queue (#25-#28) - #155

Merged
maugauwi-hash merged 2 commits into
ethos-protocol:mainfrom
willy-de7:feat/ios-offline-cache-checkin-queue-25-26-27-28
Jul 28, 2026
Merged

iOS: offline cache expiry/eviction, cold-start fix, check-in queue (#25-#28)#155
maugauwi-hash merged 2 commits into
ethos-protocol:mainfrom
willy-de7:feat/ios-offline-cache-checkin-queue-25-26-27-28

Conversation

@willy-de7

Copy link
Copy Markdown
Contributor

Summary

Implements the four related iOS offline-support issues as one coherent change, since they all touch OfflineSupport.swift/APIClient.swift/VaultStore:

What changed

OfflineSupport.swift

  • NetworkMonitor reads NWPathMonitor.currentPath synchronously at init (via a new NetworkPathProvider seam, mirroring APIClient's existing testable-init pattern) instead of defaulting isConnected = true and waiting for the first async pathUpdateHandler callback. Closes the cold-start window where a request made right after launch on an offline device would attempt (and time out on) a real network call instead of using the cache path.
  • OfflineCache now writes a sibling .meta timestamp file alongside each cached entry. New cachedAt(for:) / age(for:) expose it; an optional maxAge makes load(for:) treat over-age entries as absent.
  • OfflineCache gained a configurable maxBytes cap (default 20 MB) enforced on every save(_:for:), evicting least-recently-loaded entries first (tracked via file mtime, bumped on load(for:) without touching the separate cachedAt timestamp so staleness display stays accurate even after a cache hit).
  • OfflineCache.clearAll() wipes the cache directory; called from AuthStore.signOut() (ties into Clear All Local State on Sign-Out, Not Just the Token #10) so a second user on the same device is never served the first user's cached vault data offline.

CheckInQueue.swift / CheckInSyncService.swift (new)

  • CheckInQueue: flat-file JSON queue of PendingCheckIn(vaultID, queuedAt), the iOS counterpart to Android's Room-backed PendingCheckInDao.
  • CheckInSyncService.flush(): retries every queued check-in; only drops an entry on APIError.notFound (the vault no longer exists — a definitive rejection), leaving everything else (offline, server error, expired auth) queued for the next retry. Mirrors the non-retryable-error distinction CheckInSyncWorker makes on Android (which has finer-grained HTTP status codes available than iOS's current APIError surface; widening that is out of scope for this batch).

Stores.swift

  • VaultStore.checkIn now queues to CheckInQueue on APIError.networkUnavailable instead of just surfacing an error.
  • VaultStore.load() opportunistically flushes the queue when connectivity is present, and always keeps queuedCheckInCount (published) in sync.
  • New @Published var vaultsCacheAge: TimeInterval?, set from APIClient.vaultsCacheAge() whenever a load was served from cache.
  • AuthStore.signOut() now calls OfflineCache.shared.clearAll().

BackgroundRefreshService.swift

  • handleRefresh now also calls CheckInSyncService.shared.flush() (piggybacking on the existing hourly BGAppRefreshTask rather than registering a second background task identifier, per the issue's explicitly-allowed alternative).

NotificationService.swift

  • Added showQueuedCheckIn(count:) / cancelQueuedCheckIn(), the iOS equivalent of NotificationHelper.showQueuedCheckIn/cancelQueuedCheckIn. iOS has no true "ongoing" notification API, so this re-posts/cancels a fixed-identifier local notification; VaultStore.queuedCheckInCount backs an in-app banner for while the app is foregrounded.

Views.swift

  • VaultListView shows a banner for offline cache age ("Offline — showing vaults from X ago", via RelativeDateTimeFormatter) and for queued check-ins.
  • VaultDetailView shows a queued-check-in note under the Check In button.

Pre-existing issues found and fixed

Several files this batch had to touch didn't compile on main before this change (a prior merge — 96b0e1f, and one further back — had left duplicate/dangling declarations behind). Since these blocked compiling the exact files #25#28 needed to modify, and blocked the pre-existing test suite entirely, they're fixed here rather than skipped:

  • Stores.swift: VaultStore had two conflicting scheduleReminders() bodies and was missing applyUpdate/refreshSingle/deposit/withdraw/updateBeneficiary (already covered by existing VaultStoreTests/UI code that referenced them) — reconstructed by merging the two feature sets a bad merge had collapsed.
  • Views.swift: VaultDetailView had a duplicate load2FAStatus() and referenced ttlRemaining/refreshTTLPeriodically()/showDeposit/showWithdraw/showManageBeneficiary without declaring them.
  • BackgroundRefreshService.swift: private init() and a private(set) counter were called directly from HandleRefreshTests/BackgroundRefreshServiceTests in another file — inaccessible under Swift's access control regardless of @testable import (verified against a real Swift compiler in isolation). Widened to internal, matching the same pattern APIClient's own designated init already documents. Also fixed handleRefresh assigning through an immutable, non-class-constrained protocol parameter (task.expirationHandler = ...), and a var-across-Task{} Sendable-capture error introduced by that fix.
  • Tests/EthosProtocolTests.swift: one test method used try XCTSkipIf without being marked throws.
  • Tests/APIClientTests.swift: makeTestInstance called the private convenience init() via an Objective-C KVC hack that can't work (APIClient isn't NSObject) and wouldn't compile from another file anyway — switched to the init(baseURL:session:retryPolicy:) seam APIClient.swift already exposes (and documents) for exactly this purpose.

None of these touch behavior outside what #25#28 already required changing in these files.

Verification performed

This sandbox has no Xcode/macOS, so real xcodebuild/swift test against Apple frameworks (Network, CryptoKit, BackgroundTasks, UserNotifications, Combine, UIKit) couldn't be run directly. Instead:

  • Built a disposable Linux SPM package with minimal stand-in modules for those frameworks (matching only the API surface this codebase actually calls) and compiled the real, unmodified OfflineSupport.swift, Stores.swift, CheckInQueue.swift, CheckInSyncService.swift, NotificationService.swift, BackgroundRefreshService.swift, APIClient.swift against it — confirms these type-check.
  • Ran the new tests plus the existing BackgroundRefreshServiceTests, HandleRefreshTests, and CancellationTests against that build: 41/41 pass (13 correctly self-skip via the existing XCTSkipIf(CI) pattern for tests needing a real UNUserNotificationCenter host process, matching how ios-ci.yml already runs this suite).
  • VaultStoreTests (applyUpdate) type-checks correctly under the same harness, but couldn't execute there — Linux swift-corelibs-xctest's test discovery crashes on @MainActor-isolated XCTestCase classes, a toolchain-only limitation unrelated to this change (real Xcode/XCTest doesn't have this issue).
  • Several specific fixes (e.g. task.expirationHandler = ... through an immutable protocol parameter, private init() cross-file access) were additionally isolated and confirmed against a real Swift 5.9 compiler outside the full package, to be certain they're genuine bugs and not toolchain artifacts.
  • Manually re-verified Views.swift's SwiftUI code (which the Linux toolchain can't compile at all) against the original working implementations from the commits the bad merges diverged from, and re-read the whole file for balance/consistency.

Test plan

  • CI (ios-ci.yml, real Xcode/simulator) runs EthosProtocol-Package SPM tests + the hosted EthosProtocolTests Xcode scheme — should now pass where it previously wouldn't even compile.
  • Manually verify on a simulator: airplane mode → open app → vault list shows a cached-data banner with a relative age; check in while offline → banner + local notification show a queued count; disable airplane mode → queued check-in flushes automatically (either via the next BGAppRefreshTask fire, forced in Xcode's debugger, or by pulling to refresh).
  • Manually verify sign-out clears ~/Library/Caches/EthosProtocolOfflineCache.

Known pre-existing issues (not in scope)

  • Tests/APIClientTests.swift's APIClientOfflineCacheTests has several tests (test_GETOfflineWithCache_returnsCachedData, test_GETOfflineNoCachedData_throwsNetworkUnavailable, test_POSTOffline_neverFallsBackToCache, test_DELETEOffline_neverFallsBackToCache) that are placeholders — they compile and pass but don't actually exercise NetworkMonitor.shared.isConnected (there's no seam to fake it there yet) or call APIClient.checkIn/unregisterPushToken. Left as-is: fixing them is a test-infra improvement outside this batch's scope, and out of caution against scope creep beyond Add Expiry to OfflineCache Entries #25-Add an Offline Check-In Queue to iOS (Parity With Android) #28.
  • Tests/HostedTests/ and Tests/UI/ (XCUITest) weren't exercised by the Linux verification harness (they need a real app host / simulator either way) — unaffected by this change (no symbols they reference were touched).

Closes #25
Closes #26
Closes #27
Closes #28

Implements the offline-support batch (ethos-protocol#25-ethos-protocol#28):

- ethos-protocol#27: NetworkMonitor no longer defaults isConnected=true at cold start;
  it now reads NWPathMonitor's synchronous currentPath snapshot before any
  async pathUpdateHandler callback fires, behind a fakeable
  NetworkPathProvider seam.
- ethos-protocol#25: OfflineCache entries are now timestamped; OfflineCache.age(for:)/
  cachedAt(for:) expose staleness, an optional maxAge threshold refuses to
  serve entries older than it, and VaultStore.vaultsCacheAge surfaces this
  to the UI as "showing vaults from X ago".
- ethos-protocol#26: OfflineCache is now capped by maxBytes with LRU eviction (by file
  mtime, bumped on load without disturbing the separate cachedAt
  timestamp), and cleared entirely on sign-out.
- ethos-protocol#28: check-ins attempted while offline are now durably queued
  (CheckInQueue, flat-file JSON) instead of lost, retried by
  CheckInSyncService (piggybacked on the existing BGAppRefreshTask plus an
  opportunistic flush on reconnect), with the same non-retryable-error
  distinction Android's CheckInSyncWorker makes, and surfaced via a
  local notification + in-app banner mirroring
  NotificationHelper.showQueuedCheckIn.

Also fixes several pre-existing compile-breaking bugs in files this batch
had to touch (a botched prior merge had left Stores.swift/Views.swift with
duplicate declarations and dangling references, and a few singletons had
private initializers/setters their own existing tests called across file
boundaries) — see PR description for details.
@drips-wave

drips-wave Bot commented Jul 27, 2026

Copy link
Copy Markdown

@willy-de7 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@maugauwi-hash
maugauwi-hash merged commit b0c326b into ethos-protocol:main Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants